Skip to content

release: 0.0.28 — Tron + TON, side-panel approval merge, hot-swap fixes#51

Open
BitHighlander wants to merge 33 commits intomasterfrom
develop
Open

release: 0.0.28 — Tron + TON, side-panel approval merge, hot-swap fixes#51
BitHighlander wants to merge 33 commits intomasterfrom
develop

Conversation

@BitHighlander
Copy link
Copy Markdown
Collaborator

Summary

Promote developmaster for v0.0.28. Cuts master from 0.0.26 → 0.0.28 (the in-between 0.0.27 bump was develop-only).

What's in this release (vs master)

New chains / dApp surface

Architecture

Fixes

Release hygiene (this commit)

  • Stripped 10 leftover [TON-DEBUG] console logs from production paths (background + side panel)

Test plan

  • make build produces a clean Chrome bundle (verified locally)
  • Load unpacked into Chrome, exercise: connect → asset switch → Send (BTC, ETH, SOL, TON, Tron)
  • Receive tab works while device is unplugged (cached pubkeys path)
  • Hot-swap: disconnect device mid-session, reconnect a different one — cache resets
  • dApp connect flows for Tron (MetaMask mask) and Solana (Wallet Standard)
  • wallet_addEthereumChain shows the new approval UI

🤖 Generated with Claude Code

BitHighlander and others added 23 commits April 20, 2026 18:57
When an MV3 service worker wakes up and calls set() with an updater
(e.g. requestStorage.addEvent spreading prev into a new array), the
in-memory cache may still be null because _getDataFromStorage() is
async. The updater then runs with prev = null and [...null] throws
"TypeError: a is not iterable", aborting addEvent and leaving the
approval flow in a bad state (popup opens for a request that was
never persisted).

Load the cache synchronously-ish (await from chrome.storage) in set()
if it's still null, so every updater receives a real value.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: harden popup lifecycle against hangs and duplicates

Addresses three symptoms: popup hanging open after completion, multiple
popup windows opening per request, and the approval promise never
resolving when the user X's the popup.

methods.ts
- Replace in-memory isPopupOpen flag with chrome.windows.getAll lookup
  that survives service worker restarts. Focuses an existing popup
  (matched by URL) instead of opening a second one.
- Register chrome.windows.onRemoved exactly once at module load and
  fan out to per-request subscribers, instead of adding a new listener
  on every openPopup() call (previously leaked forever).
- requireApproval now resolves {success:false} when the popup closes
  without an eth_sign_response, so chain handlers no longer hang and
  the dapp's RPC call terminates.

chain handlers
- Every transaction_complete / signature_complete / transaction_error
  message now carries eventId so the popup can match it to the right
  in-flight request. ethereum threads the id through signMessage /
  signTypedData; solana's buildEvent mutates requestInfo.id so the
  match works end-to-end.

Transaction.tsx
- Ignores completion/error messages whose eventId doesn't match the
  current event. Previously any signature_complete would close the
  popup, slamming the window on unrelated queued requests.

ethereumHandler.ts
- Removes dead duplicate openPopup / requireUnlock (never called,
  would have bypassed dedup if it had been).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: tag outer handleWalletRequest error with eventId

The outer catch in handleWalletRequest sends transaction_error to the
popup but was the one remaining site missing the eventId scope tag.
Combined with Transaction.tsx's backward-compat rule that accepts
unscoped messages, a thrown chain handler would surface its error on
the wrong event if two events were queued — exactly the
cross-contamination this PR is fixing everywhere else.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: harden popup empty state UX and make it self-healing

Addresses the "popup open but says 'no events' with no recovery"
failure mode, plus a latent crash if requestStorage returns null.

Events.tsx
- Null-guard requestStorage.getEvents() — previously a null return
  would throw on `for...of null` and white-screen the popup.
- Wrap the fetch in try/catch and render a dedicated error state
  instead of crashing.
- Subscribe to requestStorage changes so a second request arriving
  while the popup is open becomes visible immediately (no need to
  resolve the current request first).
- Auto-close the window 3s after landing in an empty state. Covers
  the case where a request was cancelled upstream, where cleanup ran
  before the window closed itself, or where the popup was opened
  with no pending request.
- Better copy in the empty / loading / error states so the user
  knows what is happening and that the window will close itself.
- Keep currentIndex in bounds if the event list shrinks beneath it.

Popup.tsx
- Replace the placeholder "Error Occur" / "Loading ..." fallbacks
  with a proper Chakra-styled error panel that includes a Close
  button, so a rendering crash doesn't leave the user with no way
  out other than clicking the OS window close button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: clamp Events currentIndex inline to avoid one-render crash

The useEffect-based bounds check snapped currentIndex back only AFTER
the render that caused it to go out of bounds. When requestStorage's
subscribe fired and the event list shrank beneath currentIndex, the
first render after the shrink still had the stale index — passing
events[currentIndex] = undefined into <Transaction />, which reads
event.id immediately and error-boundary crashes.

Compute a clamped safeIndex during render instead. currentIndex as
state is preserved for user-driven navigation; only the array access
is guarded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: auto-close popup on fetch error, not just empty state

The error-state UI copy already said the window would close itself,
but the auto-close effect bailed on fetchError, leaving the user stuck
on a dead-end error screen — the exact failure mode this PR is trying
to remove.

Collapse the two conditions into a single shouldAutoClose predicate so
any non-actionable state (empty OR fetch failure) triggers the timer.
Cleanup still runs correctly on recovery (error clears → new events
arrive → previous cleanup cancels the timer, new effect short-circuits).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: replace broken remote logo refs with local assets

The client referenced api.keepkey.info/coins/{keepkey,pioneerMan,ethereum}.png
shortnames that never existed on the CDN (all 403). The CDN only serves
CAIP-base64-encoded filenames, which the dynamic asset-icon code already
generates correctly. This change fixes every hardcoded shortname usage:

- KeepKey brand marks across popup + side-panel switch to bundled /kk-logo.png
  (and chrome.runtime.getURL for the EIP-6963 announce icon and manifest
  web_accessible_resources so dapps can load it).
- AddDappModal default icon now uses caipToIcon('eip155:1/slip44:60'), which
  the CDN does serve.
- NetworkDropdown hides the avatar entirely when no network is selected
  instead of rendering Chakra's default silhouette from a 403 fallback.
- headerUtils.getIconUrl last-resort fallback no longer builds
  btoa(chainSymbol) URLs (always 403); returns '' so Chakra Avatar falls
  back to an initial letter.
- wallet.ts serviceImageUrl points at pioneers.dev (chrome-extension:// URLs
  can't load in the vault desktop UI).

Third-party image hosts also replaced with bundled assets:
- Animated kk.gif (5.6MB from i.ibb.co) converted to kk.webp (286KB, 240px,
  15fps) — 95% smaller, same animation.
- MetaMask fox and Keplr logos downloaded from their official repos.
- Xfi src dropped (Coming Soon overlay covers the avatar; letter fallback).

Net: zero remote image hosts remain outside the working CAIP CDN pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: move new branded assets to chrome-extension/public root

Absolute src="/kk.webp" and /brand/... paths resolve to the extension
origin root (chrome-extension://<id>/kk.webp). Vite's base:'' does not
rewrite these, so emitting them under pages/side-panel/public/ (→
dist/side-panel/) was a 404. Moving to chrome-extension/public/ lands
them at the dist root and matches the already-working /kk-logo.png
convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SidePanel.tsx default-state welcome screen uses <img src="/logo_vertical.svg">
as an opacity-0.25 watermark. The file was in pages/side-panel/public/, so
Vite emitted it to dist/side-panel/logo_vertical.svg while the absolute
path requested chrome-extension://<id>/logo_vertical.svg — 404. Same class
of bug that #41 fixed for kk.webp and brand icons.

Moving both vertical logos to chrome-extension/public/ lands them at the
dist root and matches the working /kk-logo.png / /kk.webp convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous encode (q=50, 15fps) produced visible "tracing" — residual inter-frame
prediction errors that looked like motion trails. Bumping quality to 90 and
frame rate to 20fps eliminates the smear while still landing at 560KB (90%
smaller than the original 5.6MB GIF).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The ffmpeg-produced animated WebP had broken frame disposal — every frame
composited on top of the previous, leaving "tracing" ghosts. Falling back
to a properly-optimized GIF (240px, 192 colors, coalesced + layered
optimize) lands at 690KB and animates correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Prefetch Solana pubkey during onStart so the network shows up in the
  dropdown without waiting for a dapp-initiated request. Previously the
  Solana pubkey was only derived lazily on first dapp interaction, so the
  network list silently skipped it on fresh sessions.
- Hide the total balance + Send/Receive action row during the initial
  balance fetch. Showing $0.00 + disabled buttons while loading caused a
  jittery shift once balances arrived.
- Center the Balances loading spinner full-height with a caption and
  smoother spin. Replaces the top-anchored inline spinner that looked
  cramped above the card list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: spice up balance loading screen with hero spinner and skeleton rows

Replace the plain centered spinner + "Loading balances…" text with a
multi-layer animated hero spinner (triple counter-rotating rings, pulsing
glow, breathing center dot) above skeleton cards that mirror the real asset
row layout. Adds a subtle kk-logo watermark behind everything and a teal
shimmer sweep that travels across each skeleton card staggered by 150ms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: show Solana balance and SPL tokens in side panel

Three layered bugs were each sufficient to zero out Solana balance display;
all three are fixed together because fixing any one alone leaves the chain
still broken:

1. Wrong CAIP in shortListSymbolToCaip['SOL'] / shortListNameToCaip.solana.
   Previously pointed at wrapped-SOL SPL token
   (solana:.../solana:so111…, all lowercase). Now points at native SOL
   (solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501), matching
   pioneer-caip's ChainToCaip and vault-v11's config.

2. Wrong Pioneer endpoint for Solana. /charts/portfolio returns empty
   {balances:[], tokens:[]} for any Solana pubkey (verified via direct
   curl). Vault-v11 uses /portfolio via pioneer.GetPortfolioBalances,
   which returns natives + SPL tokens in one flat array. Route Solana
   pubkeys to a third batch hitting /portfolio with the required
   key:public-* Authorization header; EVM/UTXO still go through
   /charts/portfolio for its richer Zapper/Unchained token data.

3. Response case mismatch. Pioneer echoes CAIP/networkId back in
   lowercase regardless of request casing. The side-panel asset list
   uses canonical mixed-case network IDs from ChainToNetworkId, so
   strict b.networkId === asset.networkId comparisons in Balances.tsx
   silently dropped every Solana entry. Rewrite Solana entries to
   canonical casing before they enter the merged balances array.

Also eliminates a first-run race: the initial fetchBalancesFromPioneer()
fired before prefetchSolanaPubkey() persisted the Solana pubkey, so run 1
never included Solana at all. Chain a forced refetch on prefetch resolution
so the Solana entry lands in cachedBalances before the UI mounts.

Verified against the live Pioneer API: for the exact address the client
derives from the device at m/44'/501'/0'/0', /portfolio returns
{native SOL + 3 SPL tokens}.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): prevent stale-fetch cache clobber and push balance updates to UI

Addresses two PR review findings:

1. HIGH: stale earlier fetch could clobber a later fetch's result. The
   first cold-start fetchBalancesFromPioneer() starts before the Solana
   pubkey is persisted, and the chained forceRefresh fetch runs in
   parallel. If the forced fetch finishes first (correctly populating
   cachedBalances with SOL + SPL tokens) and the original slower fetch
   finishes later, the original unconditionally overwrote cachedBalances
   back to the pre-Solana snapshot. Fix: tag each fetch with a monotonic
   latestFetchId bumped only when real work starts (not for dedup-return
   paths), and only commit to cachedBalances if this fetch is still the
   most recent (myFetchId === latestFetchId). Superseded fetches log
   their discard and return their result to direct callers without
   touching the cache.

2. MEDIUM: UI never observed late cache updates. Balances.tsx and
   SidePanel.tsx each fetched GET_APP_BALANCES once and then stopped
   listening, so the cold-start forced refetch that lands Solana after
   the panel mounts was invisible to users. Fix: background now emits
   BALANCES_UPDATED via chrome.runtime.sendMessage every time
   cachedBalances is successfully written. Balances.tsx, SidePanel.tsx,
   and Tokens.tsx subscribe to that message and re-fetch so the UI
   reflects the latest cache without the user having to manually
   refresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…back

networkIdToIcon() was mapping Solana to the wrapped-SOL SPL CAIP
(solana:.../solana:So111…), which returns 403 on
keepkey.info/coins/<base64-caip>.png. The Avatar component fell back to
rendering the first letter of the network name — a green "S" badge where
the logo should be. Native SOL's slip44:501 CAIP has a real icon (200),
matching pioneer-caip's ChainToCaip convention used elsewhere in the
codebase after PR #42.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds thin Makefile over pnpm scripts so the stack-wide "use make for
everything" convention applies here too. Also commits the current
minified build of the Solana/EVM injected script.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Port the approval UI into the side panel and remove the popup entirely.
Side panel is now the sole surface for both portfolio and dApp approvals.

Three-phase merge plus iterative review fixes across five rounds:

Phase A: port popup approval components to side-panel/src/approval and
subscribe SidePanel to requestStorage.
Phase B: background.methods.ts opens the side panel via
chrome.sidePanel.open; setPanelBehavior({openPanelOnActionClick:true})
+ action badge as user-gesture fallback; 10-minute approval timeout
replaces the old popup-closed escape hatch.
Phase C: delete pages/popup, its e2e specs, and the OPEN_SIDEBAR
background handler. Chrome-only; Firefox build hard-gated until a
replacement UI ships (task #5).

Iterative fixes touched: approval routing / id mismatch, PersonalSignTx
wire-up, bridge same-origin + falsy RPC handling, provider coexistence
(stop stomping window.ethereum), GET_PUBKEY_CONTEXT asset scoping,
Receive dedup by CAIP, ETH account removal runtime state, custom
network mirror into provider stores, GET_CHARTS networkId filter,
Clear-all covers every user-written storage, approval window routing,
accountIndex preservation end-to-end, full asset-context pass-through,
native-preference for default global send/receive, scalar balance
fallback in Transfer. New side-panel approval e2e smoke covers the
reject path.

Lint check is red on develop (470 errors pre-existing); this PR's
lint delta is ~0 after ignoring the ported approval subtree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bump every workspace to 0.0.27 via ./update_version.sh for the
release cut. Also restore chrome-extension/public/injected.js to its
esbuild-minified form — prettier had un-minified it on a prior
lint-staged run, bloating the shipped bundle and making every build
produce a noisy diff. Add the file to .prettierignore so this stays
fixed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "KeepKey Vault Required" card rendered without a background on
top of the side panel's dark theme, so the title (no color) and the
\`gray.500\` / \`gray.400\` helper text were effectively black on near-
black. Users couldn't read the "vault must be running" instruction.

Give the card an explicit dark-but-distinct background
(\`bg="gray.800"\` over the \`gray.900\` panel) with a faint border for
separation, and switch every Text to the white/whiteAlpha scale the
rest of the sidebar uses. Link color bumped to \`teal.300\` so it's
visible against gray.800.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… fetch

TON
- New tonHandler.ts wraps the vault's three-call flow:
  /ton/build-transfer (vault owns BOC + seqno + deploy detection)
  /ton/sign-transaction (device signs the body hash)
  /ton/finalize-transfer (vault assembles + broadcasts via TonCenter).
- BIP-44 path is m/44'/607'/0' (3 levels — NOT 5). Five-level paths
  make the firmware return {"error":"Failed to derive private key"}.
- Address guard (isPlausibleTonAddress) rejects anything that isn't
  UQ/EQ/kQ/0Q + 46 base64url chars or 0:/−1: raw hex, so a stale
  cache or misrouted call can't silently poison the TON pubkey slot
  with an ETH address.
- Signature normalizer: /ton/sign-transaction returns whatever shape
  hdwallet produces (unlike /tron/sign-transaction which hex-encodes
  server-side). Coerce string / Buffer / number[] / Uint8Array-like
  into 64-byte hex before handing to /ton/finalize-transfer, which
  strictly requires a hex string.
- Balance unit fix: Pioneer's /api/v1/ton/accountInfo returns decimal
  TON already (e.g. "15.701798194"), not nanoTON — the prior /1e9
  divide turned a real 15.7 TON balance into 1.5701798194e-8 and
  made the Asset page / Send page show 0.0000 TON.

Tron
- Transaction.tsx adds a 'tron' router case so approvals render via
  OtherTransaction (same generic cards Solana/TON/Ripple use) — was
  falling through to 'unknown' → "Unknown Transaction Type".
- tronHandler already did the build / sign / TronGrid broadcast and
  the vault's /tron/sign-transaction already hex-encodes, so no
  signature-shape normalization needed on this side.

Balance fetch stabilization
- fetchBalancesFromPioneer now rejects commits from fetches whose
  pubkey snapshot was taken before a concurrent addPubkey landed.
  Without this, three parallel prefetches (Solana / Tron / TON) could
  race such that the fetch with the highest id (the "latest" winner
  of the pre-existing latestFetchId check) had a stale snapshot
  missing the dynamic pubkey. Symptom: dashboard briefly showed
  correct TON / Tron balance then "overwrote" it to 0 when a stale
  but id-latest fetch committed. Guard: compare wallet.getPubkeys()
  length at commit vs at snapshot — if it grew, supersede ourselves.
- Added a #N committed log with the native-row summary for triage.

Asset context
- assetContextStorage.updateContext now REPLACES instead of merging.
  The previous {...prev, ...newContext} let stale fields from an
  earlier asset (decimals / contractAddress / token:true) leak into
  the next asset — switching from an ERC-20 to TON would carry the
  token flag forward and mis-route the Send page.

Other
- SDK bump keepkey-vault-sdk ^2.0.1 → ^3.0.1 (required by the new
  vault feat/ton-build-transfer branch).
- shared/chainConfig adds Ton + Tron entries (Chain enum,
  ChainToNetworkId, COIN_MAP_LONG).
- chrome-extension/chainConfig mirrors shortListSymbolToCaip +
  shortListNameToCaip for the same two.
- background/index.ts: fetchTonBatch / fetchTronBatch + prefetch +
  resetState on disconnect. Valid until we land real price feeds
  for TON / TRX — both hardcode valueUsd/priceUsd=0 today.
- Extension gets TON / Tron debug logs in key hot paths; cheap and
  worth keeping for the next round of triage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pioneer's /charts/portfolio silently omits TON and Tron rows when called without a queryKey. Our previous workaround used per-address /accountInfo calls that returned raw balance but no price — dashboard USD stuck at \$0.00 for both chains even when the vault's own UI showed correct values.

Append ?key=key:public-\${Date.now()} so the portfolio call returns TON and Tron with full priceUsd + valueUsd (same auth scheme /portfolio already used for Solana). Keep /accountInfo as a targeted patch for partial-response edge cases — priceless row beats missing row. Also dedupe + stale-state-aware retry on chrome.action.setIcon to fix cold-start icon flicker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The add-chain flow had five UI gaps that together rendered as
"Unknown Method / Verify before proceeding" above an infinite spinner:

  - RequestMethodCard had no case for wallet_addEthereumChain → default.
  - RequestDetailsCard routed it through LegacyTx, which reads fields
    off `unsignedTx` (null for this flow).
  - The `!unsignedTx` spinner guard hid any content until a tx built —
    which never happens for add-chain.
  - The fees tab tried to render RequestFeeCard for an event with no tx.
  - On Approve, Transaction.tsx set `awaitingDeviceApproval`, flashing
    "Please approve on your KeepKey" — but this flow never touches the
    device, the background just writes to blockchainStorage and fires
    signature_complete.

Fixes:

- New AddEthereumChainTx component — dedicated view with:
  - warning banner ("the site chooses the RPC"), plus a red banner if
    the RPC URL is plain http://,
  - chain name + chainId (hex and decimal),
  - native currency (symbol / name / decimals),
  - full, untruncated, monospaced RPC URL block (trust surface),
  - explorer link(s).
- Route wallet_addEthereumChain through the new component in
  RequestDetailsCard. Rename the spinner-skip set from MESSAGE_SIGN_METHODS
  to NO_UNSIGNED_TX_METHODS and add the type to it.
- RequestMethodCard gets a yellow "Add Network" header with an
  explainer that reminds the user to verify the RPC.
- Fees tab shows a "No transaction fees — this flow only stores the
  RPC configuration locally." placeholder.
- Transaction.tsx: add NO_DEVICE_STEP_TYPES. For those methods, Accept
  shows a minimal "Saving network configuration…" spinner state (the
  form and the device overlay both hidden) until the background emits
  signature_complete and onDismiss fires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: detect KeepKey hot-swap + drop stale cache on device change

The extension treated any reachable vault as a reachable device, with no
validation that the device currently paired with the vault is the same
one whose pubkeys we cached. That made three failure modes silent:

  1. Hot-swap on same vault — checkKeepKey kept state.deviceConnected=true
     and never re-probed, so addresses/balances pinned to the old device
     indefinitely.
  2. Extension reload with a new device — init() loaded the old pubkey
     cache without comparing deviceId against the freshly-probed one, so
     cached addresses survived even a full BEX reload.
  3. Vault auth rejection (stale apiKey after re-pair) — fetchPubkeys
     caught the error and silently fell back to cached pubkeys from the
     previous device, masking the re-pair requirement.

Four narrow changes:

- wallet.init() now compares cached deviceInfo.deviceId to the freshly-
  probed deviceId when a device is reachable. Mismatch → clear storage
  before fetchPubkeys runs. (Covers case 2.)

- wallet.handleDeviceSwitch() primitive clears in-memory pubkeys/paths,
  resets the deviceConnected flag, and wipes pubkeyStorage so the next
  fetch rebuilds from the new device. Keeps SDK alive — only the
  device-specific state is dropped.

- background/index.ts checkKeepKey now runs a periodic (30s, throttled)
  getFeatures re-probe while deviceConnected. Compares before/after
  deviceId; on mismatch calls handleDeviceSwitch which clears balance
  cache, per-chain address caches (Solana/Tron/TON), and triggers a
  fresh pubkey+balance refresh. (Covers case 1.)

- fetchPubkeys no longer silently falls back to cache on auth errors.
  Detects 401 / "unauthorized" / "not paired" shapes, clears the stored
  apiKey, and throws a user-facing message for the sidebar to surface.
  Non-auth failures (network blip, device busy) still fall back to cache
  as view-only — that path is correct. (Covers case 3.)

No user-visible UX change on the happy path. Recovery from device swap
is now automatic within ~30s on steady state, immediate on extension
reload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: rebuild default paths on device switch so refreshPubkeys has a batch

handleDeviceSwitch cleared state.paths to [], but the caller
(background checkKeepKey) then calls wallet.refreshPubkeys() which
only probes + fetches — it does not repopulate paths. fetchPubkeys
maps state.paths into the batch sent to the device, so an empty
paths array meant an empty batch, meaning the hot-swap recovery
finished with zero pubkeys and zero balances instead of the new
device's view.

Reset to getDefaultPaths() on switch so refreshPubkeys has something
to query against.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: re-prefetch Solana/Tron/TON pubkeys on device switch

refreshPubkeys only re-runs the default-path batch via
wallet.getPublicKeys — that batch covers BTC / LTC / ETH / Cosmos /
etc., but *not* SOL / TRX / TON. Those three are derived dynamically
at onStart via prefetchSolanaPubkey / prefetchTronPubkey /
prefetchTonAddress (each calls its chain's `*GetAddress` method and
stashes the result in a per-chain cache + adds a pubkey to state).

After a hot-swap:
  - handleDeviceSwitch clears the per-chain caches (resetXState)
  - refreshPubkeys repopulates the default batch
  - but nothing re-runs the three prefetches
  → SOL/TRX/TON vanish from the network dropdown / asset list / balance
    fetch until the user reloads the extension or manually navigates to
    those chains, which racy-triggers the lazy getAddress in each handler.

Fan out all three prefetches in parallel via Promise.allSettled after
refreshPubkeys. Non-throwing by design, so if one chain's derivation
fails (minFirmware mismatch, etc.) the other two still recover.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…etwork-ids (#47)

Two related fixes that together make TRC-20 (USDT-TRON) appear on the
dashboard for the first time.

1. One endpoint instead of two
---------------------------------
Before: the Pioneer call was split across /charts/portfolio (for
EVM/UTXO/Cosmos/TON) and /portfolio (for Solana only), with a separate
fallback for TON/TRON partial responses. /charts/portfolio is a fast
cached path backed by pre-computed charts + Zapper-provided EVM
tokens; it does NOT run Pioneer's SPL / TRC-20 / ERC-20 auto-discovery
blocks that live in balance.controller.ts. Only /portfolio
(GetPortfolioBalances) hits those — which is why the vault, which uses
/portfolio exclusively via pioneer-client, saw USDT while the BEX did
not.

Consolidated every pubkey (Solana + Tron + EVM + UTXO + Cosmos + TON
+ Ripple + THORChain) onto /portfolio in one call, matching vault's
GetPortfolioBalances flow exactly. Classification (native vs token)
uses the same rule as vault: caip path not in {slip44:, native:}
OR type === 'token' OR (isNative === false && contract).

Net: -118 lines vs. the three-batch split. Slower per call (no
pre-warm charts cache) but correct, and the user explicitly accepted
the latency trade.

2. Tron has two network-ids; alias them
------------------------------------------
Pioneer emits the native TRX balance under `tron:27Lqcw` (CAIP-2
genesis-hash convention), but TRC-20 tokens under `tron:0x2b6653dc`
(hex chain-id convention). Both refer to Tron mainnet. Pioneer's own
ChainToNetworkId declares `tron:0x2b6653dc` as canonical (vault
consumes that and works); our BEX has hardcoded `tron:27Lqcw`
throughout `chainConfig.ts` and `tronHandler.ts`. Rather than rewire
every pubkey cache entry in users' storage, alias Pioneer's response
ids at the normalizer:

  NETWORK_ID_ALIASES = {
    'tron:27lqcw'      -> 'tron:27Lqcw'          // casing
    'tron:0x2b6653dc'  -> 'tron:27Lqcw'          // cross-convention
    'solana:5eykt4...' -> 'solana:5eykt4UsFv8...' // existing Solana casing
  }

Rewrites both `networkId` and the network prefix of `caip`. The
side-panel's strict `networkId === 'tron:27Lqcw'` filter now matches
both native rows and TRC-20 token rows, so USDT-TRON appears on the
Tron asset page.

Known ceiling: the canonical-id mismatch is tech debt. Switching our
BEX to `tron:0x2b6653dc` would remove the alias entry but requires a
storage migration (existing pubkey caches have `networks:
['tron:27Lqcw']`). Deferred — alias is localized and cheap.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tron): send the right asset when user picks a TRC-20 token

The side-panel Send flow builds a `transfer` request with the asset's
caip in the payload, but the Tron handler was ignoring it — every send
called `buildTronTransfer` (TronGrid's /createtransaction endpoint,
which only builds native TRX transfers). So clicking USDT-TRON and
hitting send silently sent TRX instead.

Branch the handler on the caip:

  tron:*/token:T...   or   tron:*/trc20:T...   →  TRC-20 transfer
  tron:*/slip44:195                            →  native TRX transfer

For the TRC-20 path:
- Parse the contract address out of the caip (accepts both of
  Pioneer's Tron network-id conventions — 27Lqcw and 0x2b6653dc).
- Read decimals from assetContextStorage (populated by the side-panel
  when the user clicks the asset); default to 6 if missing so the
  common USDT/USDC case still works on cold state.
- Convert the human amount → base units via padded-string math (same
  approach as trxToSun, but parametrised by decimals). BigInt throughout
  so 18-decimal tokens don't overflow Number.
- ABI-encode `transfer(address,uint256)`:
    a9059cbb | recipient_20bytes_left_padded_to_32 | amount_uint256
  The 0x41 Tron prefix is stripped from the address before padding.
- POST /wallet/triggersmartcontract to TronGrid, get raw_data_hex back
  in the same shape as /createtransaction so signing + broadcast reuse
  the native code path.

signTronViaRest now accepts `amountRaw: string | number` — passing
Number for large TRC-20 values would silently truncate at ~2^53 base
units. Vault's endpoint stringifies internally; we just hand it through.

The approval event carries eventKind='trc20-transfer' + contractAddress
for the OtherTransaction approval UI; firmware still decodes raw_data
itself so there's no device-side regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tron): approval UI renders TRC-20 sends correctly

The approval-details renderer in `pages/side-panel/src/approval/other`
has two hardcoded assumptions:
  1. RequestMethodCard only recognizes type='transfer' — anything else
     shows "Unknown Method".
  2. RequestDetailsCard reads `unsignedTx.payment.{destination,amount}`
     and divides amount by 1,000,000 (XRP drops / TRX sun).

Previous commit emitted type='trc20-transfer' and no `payment` block,
so clicking USDT and tapping Send surfaced "Unknown Method" / N/A.
Fix it at both ends:

Handler (tronHandler.ts):
- Emit type='transfer' for both native TRX and TRC-20 — no semantic
  win from a sub-kind at the UI layer; `unsignedTx.contractAddress`
  + `caip` distinguish when downstream code actually needs to branch.
- Populate `unsignedTx.payment = { destination, amount, decimals, symbol }`
  so the renderer has what it expects. `amount` stays as a raw-base-units
  decimal string (not a Number) to preserve precision for high-decimal
  TRC-20 tokens.
- Carry `decimals` on the event (sourced from assetContextStorage for
  TRC-20, hardcoded 6 for native) so the UI can format without a
  separate asset lookup.

UI (RequestDetailsCard.tsx):
- New `formatAmount()` helper. Integer-string inputs route through a
  BigInt-safe path (slice to whole/frac, trim trailing zeros). Other
  inputs fall back to Number/Math.pow to preserve the existing Ripple
  display behavior for any legacy rows that still write a plain number.
- Decimals cascade: `payment.decimals` (event hint) → `assetContext.assets.decimals`
  → 6 (matches XRP drops and TRX sun — the two chains this renderer has
  historically served).
- Symbol pulled from `payment.symbol` or `assetContext.assets.symbol` and
  appended to the amount — tells the user at a glance what they're sending.
- Suppress the destinationTag row entirely when it's undefined (every
  non-Ripple chain previously rendered a misleading "none").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ui): formatAmount handles zero-decimal assets

With decimals === 0, str.slice(0, -0) returns '' (slice treats -0 as 0
and the negative second arg short-circuits to empty) and str.slice(-0)
returns the entire string. Without guarding, "123" at 0 decimals
rendered as ".123".

Fast-path `decimals <= 0` returns the integer string as-is, which is
the correct formatting for any zero-decimal token.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ui): approval renderer branches on unsignedTx.kind for Tron

Previous pass unified Tron dApp events on type='transfer' so they'd
land on this renderer at all. Side-effect: contract calls (swaps,
stakes, approvals, anything other than native TRX or TRC-20 transfer)
displayed as "basic transfer" — actively misleading to the user
clicking Approve on-device.

Teach the renderer to branch on `unsignedTx.kind` (the chain-specific
refinement the Tron handler attaches):

RequestMethodCard:
- kind='contract-call'    → "Smart Contract Call" (orange warning icon,
                            copy asks user to verify before approving)
- kind='trc20-transfer'   → "Token Transfer"
- kind='trx-transfer' / missing → existing basic-transfer label

RequestDetailsCard:
- Contract-call path renders Contract + Function selector instead of
  recipient/amount — raw call_value in TRX shown only when non-zero
  (most TRC-20 flows have call_value=0; swaps spending native TRX
  have a real value worth surfacing to the user).
- Native + TRC-20 transfer paths unchanged.

This keeps the dApp integration unblocked (SunSwap-style contract
calls still sign) while giving the user accurate UX at approval time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tron): carry kind on side-panel events + gate asset-context fallback

Two fixes that go together:

1. Side-panel transfer case now sets unsignedTx.kind
   ('trc20-transfer' | 'trx-transfer'). Previously clicking USDT in
   the asset list and hitting Send produced an event without `kind`,
   so the approval UI's RequestMethodCard fell through to the default
   'transfer' label — "basic transfer" rather than "Token Transfer"
   even though we were actually signing a TriggerSmartContract.

2. RequestDetailsCard's asset-context fallback is now gated on
   ctx.caip === event.caip. Without the guard, dApp-originated sign
   events (which carry their own caip — e.g. tron:*/token:TR7NHq...)
   could inherit symbol/icon/decimals from whatever the user last
   clicked in the side-panel asset list. Concrete failure mode a
   reviewer flagged: a dApp USDT-TRON approval rendering with the ETH
   icon + "ETH" symbol because ETH was the last selected asset.

   Side-panel sends keep the context fallback because the user just
   clicked the asset — caips match, fallback fires normally. Only
   the non-matching case is suppressed.

   Also gates the Avatar `src` on the same ctxMatches check so a
   wildly-off icon can't leak through to the dApp approval pane.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(dapp): Tron injection + functional MetaMask masking

Two related dApp-injection improvements shipped together:

Tron dApp support
-----------------
Mount window.tronLink + window.tronWeb shims that mirror TronLink's API
surface. dApps connect via the standard "Connect TronLink" button; our
shim intercepts (isTronLink: true), opens the KeepKey approval side
panel, signs via the vault, and returns the signed tx for the dApp to
broadcast.

- New injected provider in chrome-extension/src/injected/tron-provider.ts
  * tronWeb.trx.{sign, sendRawTransaction, getBalance, getAccount}
  * tronWeb.transactionBuilder.{sendTrx, triggerSmartContract}
  * tronWeb.utils.{isAddress, toSun, fromSun, toHex}
  * Direct TronGrid for reads/builds/broadcast, extension pipeline for signing
- Background handler extended with tron_requestAccounts, tron_sign,
  tron_signMessage (throws until vault adds the endpoint)
- tron_sign decoder handles TransferContract (native TRX), TriggerSmartContract
  with transfer(address,uint256) (TRC20/USDT), and passes any other
  TriggerSmartContract through for firmware to validate
- Verified on SunSwap: detection + connect + address populate. Signing
  paths need on-device verification per firmware capability.

MetaMask masking pipeline
-------------------------
The Settings → Enable MetaMask Masking toggle was cosmetic — isMetaMask
and window.ethereum mounts were hardcoded on. Made it actually work
end-to-end, default off.

- Content script reads 'masking-settings' from chrome.storage before
  injection, stamps data-masking="{...}" on the <script> tag
- Injected script parses dataset.masking at startup and branches:
  * isMetaMask reflects enableMetaMaskMasking
  * window.ethereum mount gated on the flag (otherwise EIP-6963 only)
  * window.xfi mount gated on enableXfiMasking
- When MetaMask masking is ON, also announce via EIP-6963 with
  rdns: 'io.metamask' so SDKs that key off the canonical MetaMask rdns
  (MetaMask SDK, Dynamic.xyz's MetaMaskConnector, RainbowKit) find us.
  Same trick Rabby uses.
- Known ceiling: MetaMask SDK v0.28+ does anti-impersonation probes
  beyond EIP-6963 that we can't realistically beat. Works on simpler
  legacy sites; SDK-hardened dApps need WalletConnect (future work).
- Removed dead, wrong-key-reading getMaskingSettings handler from
  background/index.ts.
- Single diagnostic log per page load: "[KeepKey] masking: metamask=on/off ..."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tron): keep TRC-20 amounts as strings through dApp sign path

decodeTronTx was holding the decoded TRC-20 amount as BigInt, then
casting to Number before stashing it on DecodedTronTx.sunAmount and
passing it to signTronViaRest + the approval event. Any 18-decimal
token (most non-stablecoin TRC-20s) exceeds Number.MAX_SAFE_INTEGER
in base units, so the value silently rounds before the vault sees it
— firmware displays the wrong amount on-device and the signed tx
spends the wrong amount on-chain.

Changes:
- DecodedTronTx.sunAmount (number) → amountRaw (decimal string).
  Renamed to signal "this is base units not native sun".
- TransferContract decode emits String(v.amount); TRC-20 decode emits
  BigInt.toString(); contract-call emits String(callValue). All paths
  preserve precision.
- signTronViaRest signature widened to `string | number | bigint`.
  Internal stringification uses bigint.toString() when applicable so
  nothing re-enters the Number casting path.
- Approval event now carries amountRaw (not sun) so downstream UIs
  that display the raw-unit hint get the untruncated value.

Native TRX amounts never overflow Number (max 90B TRX in sun = 9e16,
fits in 2^53 comfortably), but the change makes that path uniform
and the code easier to audit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tron): dApp sign events land on the shared approval UI

Previous pass changed side-panel Send to emit type='transfer' +
unsignedTx.payment, but the dApp sign path (tron_sign) was still
emitting type='trc20-transfer' / 'contract-call' and had no payment
block. The shared /approval/other renderer only cases 'transfer' and
reads unsignedTx.payment.*, so dApp TRC-20 + contract-call approvals
surfaced as "Unknown Method" / N/A.

Unify on the same contract:
- type='transfer' for all Tron paths, always.
- decoded.kind now travels on unsignedTx.kind for downstream branches
  that actually care about native vs TRC-20 vs generic contract-call
  (storage/history, txid explorer link selection, etc).
- Populate unsignedTx.payment = { destination, amount, decimals, symbol }
  so RequestDetailsCard renders without a second asset-context
  round-trip.
- amount stays as a raw base-units decimal string (preserved from the
  previous amountRaw fix).
- decimals defaults:
    trx-transfer     → 6 (sun)
    contract-call    → 6 (call_value is TRX attached to the call)
    trc20-transfer   → 0, so the UI shows the raw integer we decoded.
                       We don't have the token's decimals from the
                       raw_data alone and the asset context isn't the
                       dApp's caller — showing a raw integer is less
                       misleading than applying the wrong divisor.
                       Future work: decodeTronTx can call the contract's
                       decimals() view or look up from assetData.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tron): set explicit TRX symbol on contract-call payment

Review follow-up: contract-call events left payment.symbol undefined,
which previously leaned on the UI's asset-context fallback for the
"TRX sent" row. Combined with the UI now gating that fallback on
ctx.caip === event.caip (#48), a dApp-initiated contract call whose
caip doesn't match the side-panel's selected asset would render the
call_value row with no symbol at all.

Two tightenings in one:
- contract-call & trx-transfer both emit symbol='TRX' explicitly —
  the call_value on any TriggerSmartContract is always native TRX,
  so stating it in the handler is accurate and removes the UI's
  need to guess.
- trc20-transfer stays symbol=undefined — we don't know the token's
  symbol without an on-chain symbol() call or an assetData lookup.
  The UI's caip-match gate on asset context will decline to show a
  wrong symbol when the dApp caip differs from whatever the side
  panel had selected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tron): scope trc20-transfer event caip to the token contract

Review follow-up to the caip-match gate in #48: dApp TRC-20 events
were still emitting caip: TRON_CAIP (tron:27Lqcw/slip44:195 — the
native-TRX caip). The UI's ctx.caip === event.caip guard matched
when the user had TRX selected in the side panel, leaking the TRX
symbol/icon onto a USDT approval — exactly the failure the gate
was supposed to block.

Emit a token-scoped caip for trc20-transfer:

  tron:27Lqcw/token:${decoded.contractAddress}

Now the fallback only fires when the side-panel happened to have
this exact token selected. Any other selection (TRX, a different
TRC-20, or an unrelated chain) leaves symbol/icon empty, which
is the correct behavior — the handler still populates
payment.decimals=0 so amounts render as raw base units.

trx-transfer keeps TRON_CAIP (legitimate native TRX match desired).
contract-call keeps TRON_CAIP too — its renderer owns its own
labels (Contract:, Function:, "TRX sent:") with a hardcoded 'TRX'
fallback on the call_value row, so the caip match affects only
the avatar icon. Not worth the extra complexity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(ui): dark theme + header + asset list refresh (Tier 1–3 of design handoff)

Adopts the Claude Design handoff's visual language for the side panel —
darker base (#0b0d10), Inter + JetBrains Mono typography, gold-gradient
primary buttons, and tokenized surfaces/lines. Shield badge on the left
doubles as both "go home" button and device-status indicator (gold
pulsing when transient, green when paired, red when errored), replacing
the separate status icon.

Bug fixes along the way:
- "Add blockchain" picker now hides the dashboard block and is resettable
  from the home button (state lifted out of Balances into SidePanel).
- Asset-detail drawer no longer renders a redundant "Ethereum" title bar;
  a minimal floating back chevron replaces it.
- Header row heights unified at 32px so the Network/Account pills and
  shield all sit on the same baseline.
- TronLink badge next to "Tron" on the asset page (passive, no link —
  TronLink is an unaffiliated wallet; we just signal protocol use).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(side-panel): receive tab loads offline + auto token discovery

Receive tab
- Derive UTXO receive addresses locally from xpubs (BIP32 +
  script-type encoding) instead of round-tripping the device. Page
  now renders in view-only mode and survives hot-swaps. Adds
  @scure/bip32, @scure/base, @noble/hashes; new utxoDerive.ts covers
  BTC (legacy/segwit/native segwit), LTC, DOGE, DASH, BCH cashaddr.
- Fix the account dropdown — it looked up pubkeys by .address|.master
  (both empty on UTXO) so clicks were silent no-ops. Keys by
  pubkey.note now and shows derived addresses, not truncated xpubs.
- Labels read "Account 0 · Native Segwit" etc. so the three BTC
  entries that all share account index 0 are distinguishable.
- Tighten GET_UTXO_ADDRESS match order so note wins over scriptType;
  multiple p2wpkh accounts can be disambiguated.
- Center Receive + AssetDetail vertically; hide the Tokens tab on
  UTXO chains instead of rendering an empty state.

Token auto-discovery
- Replace the per-chain prefetch().then(fetch(true)) chains with a
  single Promise.allSettled([sol, tron, ton]).then(fetch(true)).
  The old shape raced three parallel fetches that the snapshot
  staleness guard then discarded — SPL/TRC-20 tokens silently never
  landed in cachedBalances, and users had to press "Discover Tokens"
  to see them.
- Wire the Discover/Refresh button to REFRESH_ALL_BALANCES so it
  forces a Pioneer round-trip instead of re-reading the cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(receive): address review findings on UTXO derivation

- script_type, not scriptType: raw pubkey objects use snake_case (matches
  chainConfig.ts and the SDK request shape in wallet.ts). Reading
  .scriptType was always undefined, so segwit / native-segwit accounts
  silently fell through to the p2pkh branch and rendered legacy "1..."
  addresses for "Account 0 · Native Segwit" entries.
- Drop the chrome.storage.session derived-address cache. Local BIP32 +
  hash160 + encoding is microseconds; the cache only added a stale
  surface — it was keyed by (networkId, scriptType, note) without the
  xpub or device id, so a hot-swap in the same session could return the
  previous device's address for the same note.
- Pick the UTXO Receive default address from pubkeyContext.note (the
  header's selected account) instead of ctxAsset.pubkeys[0]. After
  SET_ASSET_CONTEXT, the asset's pubkeys field carries every pubkey on
  the network, and idx===0 was the first configured chainConfig path
  (legacy BTC), not the user's selection. New effect resolves
  addressByNote[pubkeyContext.note] when both are present, falls back
  to pubkeys[0].note only if no context is set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(receive): carry header-selected UTXO script_type into pubkey scope

The high-finding from the second review pass: header-selected UTXO
accounts weren't reliably carried into Receive. Header fired
SET_ASSET_CONTEXT with the selected pubkey, but the background
overwrote asset.pubkeys with all network pubkeys, and
GET_PUBKEY_CONTEXT only restored by accountIndex — UTXO accounts share
an accountIndex (BTC's Legacy / Segwit / Native Segwit all live at
account 0), so it always fell back to scoped[0] and Receive opened on
the first configured chainConfig path.

- NetworkAccountHeader.setAssetContext now puts script_type on the
  asset alongside accountIndex (snake_case to match raw pubkey shape).
- GET_PUBKEY_CONTEXT prefers script_type for the scoped lookup, then
  falls through to accountIndex (multi-account EVM) and finally
  scoped[0]. Receive's existing pubkeyContext.note → addressByNote
  resolver picks up the right entry without further changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(receive): disambiguate UTXO accounts that share a script_type

Previous fix carried script_type through SET_ASSET_CONTEXT, but
multiple BTC paths share each script_type (chainConfig has account 0
+ account 1 both at p2wpkh, plus several legacy accounts). The header
also keyed rows on script_type alone, so account-1 Native Segwit
collapsed onto account-0 in the dropdown, and even if surfaced
separately GET_PUBKEY_CONTEXT would have matched the first p2wpkh
(account 0).

- AccountItem gains an optional `note` field. headerUtils' BTC and
  UTXO builders key rows on `pk.note` (unique per chainConfig path)
  and append "· Account N" to the label only when the script_type
  repeats. Only the first p2wpkh row is flagged isDefault now.
- NetworkAccountHeader.setAssetContext sends `note` alongside
  `script_type` and `accountIndex`.
- GET_PUBKEY_CONTEXT match priority is now note → script_type →
  accountIndex → scoped[0]. Note is the only identifier that's
  unique across every chainConfig path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(receive): make UTXO defaults agree across all entry points

Header dropdown was correct, but two entry points still desynced:

- Global Receive / dashboard / asset list: SidePanel.handleGlobalReceive
  picks a default balance row that has no note or script_type, so the
  background's GET_PUBKEY_CONTEXT fell through to scoped[0] (legacy
  BTC). The header could read "Native SegWit" while Receive surfaced
  a "1..." address. Background SET_ASSET_CONTEXT now defaults a UTXO
  asset that arrived without note/script_type to the network's first
  p2wpkh pubkey (else scoped[0]) before storing.
- Header auto-default: the useEffect that picks the default account on
  mount/network-change set local selectedAccountKey only — it never
  fired SET_ASSET_CONTEXT. Stored context could be stale or absent
  while the dropdown showed Native SegWit. Effect now calls
  setAssetContext for the chosen account when a network is selected.
  Reordered the effect to sit after setAssetContext's useCallback so
  it isn't referenced in the temporal dead zone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(receive): close five remaining gaps in UTXO selection plumbing

1. Header restore by note (was: accountIndex only). UTXO accounts
   share an accountIndex, so a stored "BTC Native Segwit account 0"
   round-trip would resolve to whatever chainConfig path landed first
   at accountIndex 0 — usually legacy. fetchPubkeys now seeds
   desiredNote / desiredScriptType / desiredAccountIndex from the
   stored asset context.

2. ASSET_CONTEXT_UPDATED resync. Listener feeds the same desired*
   state, so within-network context changes now retarget the dropdown
   instead of leaving selectedAccountKey pinned to a stale row. The
   auto-select effect compares before firing setAssetContext, so the
   broadcast back from our own SET_ASSET_CONTEXT can't loop.

3. Per-chain UTXO fallback in background SET_ASSET_CONTEXT. Previously
   defaulted every bip122:* asset to first p2wpkh, which silently
   shifted LTC from p2pkh-default (header's items.length===0) to
   native segwit. Now BTC keeps p2wpkh-first, other UTXO uses
   scoped[0] — both match the header builders.

4. Auto-default no longer opens AssetDetail. setAssetContext is now a
   pure data setter (sends SET_ASSET_CONTEXT, returns the asset). The
   click handlers (handleNetworkSelect / handleAccountSelect) call
   onSelectNetwork explicitly, so the auto-default effect can sync
   stored context without an unprompted drawer-open on cold start.

5. AssetDetail derives UTXO addresses. asset.pubkeys[0].address is
   empty on UTXO and Pioneer's /portfolio stuffs the xpub into
   b.address (background/index.ts:439), so the address bar / copy /
   explorer link were either blank or showing an xpub. New UTXO
   branch in the address-fetch effect calls GET_UTXO_ADDRESS with
   the asset's note + script_type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(side-panel): don't auto-open AssetDetail on every context broadcast

The ASSET_CONTEXT_UPDATED listener fires for every SET_ASSET_CONTEXT
the background receives — including the header auto-default sync we
just added (cold-start restore + ASSET_CONTEXT_UPDATED resync) and
dApp-driven chain switches. With onAssetDetailOpen() unconditional
in the listener, the drawer would still pop up unprompted even
after we made setAssetContext a pure data setter.

Drop the auto-open. setSelectedAsset still runs, so an already-open
drawer reflects the new context. Explicit opens already go through
handleAssetSelect (asset list click, header click), which sets
selectedAsset and calls onAssetDetailOpen together.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-release cleanup before promoting develop → master.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@BitHighlander
Copy link
Copy Markdown
Collaborator Author

🔴 HOLD — release blocker identified

Uniswap swap regression — Permit2 typed-data signs but the swap flow fails downstream. Full retro and handoff notes:

📄 RETRO_uniswap_swap_release_blocker.md (committed separately)

Do not merge until that retro's "Concrete next steps" section is closed out.

BitHighlander and others added 5 commits April 28, 2026 00:32
Captures the diagnostic conversation, hypotheses tried/ruled out,
test scaffolding built in keepkey-vault-v11/projects/keepkey-sdk,
and concrete next-step ordering for whoever picks this up.

Holds release of 0.0.28 (PR #51).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per user feedback: handoff is cross-repo (keepkey-client →
keepkey-vault-v11 SDK + firmware submodule + private memory),
so every file pointer must be a full /Users/... path. Added a
File Index section so the next reader doesn't have to grep for
which clone holds which file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ethers v6 provider.call(tx) and provider.estimateGas(tx) both take a
single argument; passing positional (callParams, blockTag, stateOverride)
silently drops blockTag AND stateOverride. Uniswap's pre-quote flow uses
eth_call with stateOverride to simulate the swap as if the not-yet-
broadcasted Permit2 approval were in place — when we strip the override
the simulation runs against real state where the user has 0 allowance,
reverts, and Uniswap rejects the quote (/v1/swap → 404).

Switch both handlers to provider.send('eth_call', params) /
provider.send('eth_estimateGas', params) so the dApp's params arrive
byte-identical at the RPC.

Also extend [HANDOFF] instrumentation to log params alongside the
return value at every BEX → content-script boundary and at the
page-side resolve, so cross-chain audit of a swap flow is possible
via grep '[HANDOFF]'.

Drops the SET_ASSET_CONTEXT-driven EIP-1193 emit block per earlier
diagnosis (ruled out as the root cause but the timing was still wrong).

Refs: HANDOFF_uniswap_swap_v2.md, RETRO_uniswap_swap_release_blocker.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end trace of EVM fee handling through BEX → vault REST →
hdwallet-keepkey → firmware. Confirmed no layer mutates fees;
what the dApp supplies is what gets signed and broadcast.

Documents:
- The two adjacent defects fixed this session (nonce 'latest'→
  'pending', JSON-RPC `gas` honored alongside `gasLimit`)
- The eth_feeHistory gap (RPC dApps depend on for percentile
  fee estimates — currently throws "method not supported")
- Recommended placement and shape of the user-visible
  fee-warning + bump policy when dApp suggests under-floor fees
- Files left uncommitted in worktree for review

Refs: HANDOFF_uniswap_swap_v2.md, RETRO_uniswap_swap_release_blocker.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… logs

Three independent defects in ethereumHandler.ts that surfaced during
the Uniswap swap regression audit:

1. signTransaction + handleTransfer queried getTransactionCount with
   'latest' block tag, counting only confirmed txs. Re-attempting a
   swap while a prior attempt was still pending would reuse the same
   nonce and trigger EIP-1559's replacement-underpriced rule (requires
   +10% on both fees). The replacement tx silently sat in mempool
   until eviction — the "pending forever, eth_getTransactionByHash
   returns null" symptom we tracked. Switched both sites to 'pending'.

2. signTransaction only consulted transaction.gasLimit (camelCase),
   so the dApp's `gas` field (per JSON-RPC spec) was silently dropped
   and we re-estimated against publicnode. Now honors transaction.gas
   as a fallback before estimation.

3. Added [HANDOFF] decode-friendly logs at three more boundaries:
   eth_sendRawTransaction (dApp-supplied raw tx), the full signed
   serialized output of eth_signTransaction, and the broadcast
   request/result. Greppable end-to-end with the existing [HANDOFF]
   instrumentation.

Refs: HANDOFF_fee_pipeline_audit.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three independent improvements to the EVM signing surface, motivated
by the Uniswap stuck-pending regression diagnosed in HANDOFF_*.md.

1. eth_feeHistory passthrough — modern dApps (incl. Uniswap's UI via
   ethers v6 fee estimator) use this for percentile-based fee math.
   Previously fell through to "method not supported" → dApp dropped to
   stale eth_gasPrice fallback, supplying low fees.

2. Fee-warning + user override:
   - New chrome-extension/src/background/chains/feeFloors.ts: per-chain
     static minimums (1 gwei mainnet, 30 gwei polygon, 3 gwei BSC, etc.)
     OR'd with currentBaseFee*1.1 (always-correct dynamic floor).
   - handleSigningMethods now probes feeData + base fee, builds a
     FeeWarning blob if dApp's maxFeePerGas falls below the floor, and
     attaches it to the approval event.
   - New side-panel/src/approval/evm/FeeWarningBanner.tsx renders an
     inline banner above the tx-detail tabs with three choices: keep
     dApp's, use suggested, or custom (two number inputs, gwei). Choice
     is persisted to the event via requestStorage.updateEventById.
   - Approve button is disabled until a choice is made.
   - sendTransaction reads event.feeChoice from storage at sign time
     and overrides transaction.maxFeePerGas / maxPriorityFeePerGas
     before handing to signTransaction. Fee policy never silently
     mutates — user always picks.

3. Nonce visibility (read-only this round):
   - Same probe in handleSigningMethods also fetches latest + pending
     transaction counts and computes willReplace if the dApp set a
     nonce equal to a pending one.
   - New side-panel/src/approval/evm/NonceInfoRow.tsx renders a small
     row above the tabs: "Nonce N (next available)" green, "N
     pending tx ahead" yellow, or "Replaces pending tx — needs +10%
     on both fees" red. Etherscan deep-link for mainnet.
   - Manual nonce override + cancel-tx flow deferred to follow-up.

Build green. The user must reload the unpacked extension at
chrome://extensions for the changes to take effect.

Refs: HANDOFF_fee_pipeline_audit.md, HANDOFF_uniswap_swap_v2.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@BitHighlander
Copy link
Copy Markdown
Collaborator Author

Today's progress (commit 1ad6eb1 on develop)

Three new features shipped that should resolve the Uniswap stuck-pending regression:

1. eth_feeHistory passthrough — modern dApps (incl. Uniswap UI via ethers v6 fee estimator) need this for percentile-based fee math. Previously fell through to "method not supported" → dApp dropped to stale eth_gasPrice. Now: 3-line raw passthrough.

2. Fee-warning + user override (UI)

  • Per-chain static floors (1 gwei mainnet, 30 gwei polygon, etc.) OR'd with currentBaseFee * 1.1 as the always-correct dynamic floor — see chrome-extension/src/background/chains/feeFloors.ts.
  • Background detects under-floor fees, attaches feeWarning blob to approval event.
  • Side-panel banner (pages/side-panel/src/approval/evm/FeeWarningBanner.tsx) shows three choices: keep dApp's / use suggested / custom (gwei inputs).
  • Approve button disabled until user picks. No silent bumping.
  • sendTransaction reads feeChoice at sign time and overrides maxFeePerGas/maxPriorityFeePerGas before vault signing.

3. Nonce visibility (read-only)

  • Probe latest + pending tx counts, attach nonceInfo to event.
  • Inline row above tabs: green = next-available, yellow = N pending ahead, red = "replaces pending tx, needs +10% on both fees" with etherscan deep-link.
  • Manual nonce override + cancel-tx flow deferred to follow-up.

Plus baseline fixes from earlier today (8d65cbf): nonce uses 'pending' not 'latest' in two sites, JSON-RPC gas field honored, [HANDOFF] decode-friendly logs at every tx-broadcast hop.

Reload the unpacked extension at chrome://extensions to pick up the new build.

PR title still says HOLD — won't unblock until Uniswap swap is verified end-to-end on the new build. Suggested verification: clear the two stuck pending txs first (0x3d5b…, 0x9fd9…), then retry. The new nonce-pending logic should prevent the collision pattern that caused the original failures.

BitHighlander and others added 4 commits April 29, 2026 19:16
…heck diagnostic (#55)

* fix(eth): passthrough eth_getTransaction* + eth_getBlockByNumber to preserve JSON-RPC shape

Three read handlers were calling ethers v6 wrapper methods (provider.getTransaction,
provider.getTransactionReceipt, provider.getBlock) and returning the resulting class
instances to the dApp. Field names diverge from the JSON-RPC spec (gasLimit vs gas,
data vs input, index vs transactionIndex, signature.{v,r,s} vs flat) and methods
are stripped during chrome.runtime structured-clone. dApps parsing the spec shape
silently fail to reconcile the response — Uniswap's "Confirm in wallet" hangs even
after the tx is on-chain because eth_getTransactionByHash never resolves to the
expected shape.

Mirrors the pattern in 9cf2bbe (eth_call + eth_estimateGas) on the read side.

Also adds a post-broadcast drop-check (8s + 45s setTimeouts) that logs and emits
chrome.runtime tx_drop_warning when the broadcast hash is invisible to our RPC —
diagnostic for the low-tip eviction failure mode tracked in
RETRO_uniswap_swap_dropped_tx.md.

See docs/RPC_PASSTHROUGH_AUDIT.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(eth): tip-aware fee-warning + smart-contract detection fix

Fee-warning now flags two distinct failure modes instead of one:
- maxFeePerGas below floor (existing behavior, preserved)
- maxPriorityFeePerGas below the per-chain tip floor (new)

Tip floor matters because dApps often pad maxFee well above floor while
keeping tip below 1 gwei. Such txs are accepted by an entry node, never
gossiped to miners, and silently evicted — see RETRO_uniswap_swap_dropped_tx.md.

The FeeWarning shape now carries trigger ('maxFee' | 'tip' | 'both'),
priorityFloorWei, and effectiveTipWei so the side-panel banner can title
+ explain the warning correctly. Existing 'dapp/suggested/custom' override
paths are unchanged.

Side-panel RequestMethodCard previously read transaction.request.data,
but transaction.request is the raw JSON-RPC params *array* — request.data
is always undefined, so Universal Router calls (data 0x3593564c…) rendered
as a green-check 'Simple transfer'. Now reads transaction.unsignedTx.data
(matching what the Details tab uses) so contract calls correctly render as
yellow 'Smart Contract — review carefully'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: RPC passthrough audit + dropped-tx retro

RPC_PASSTHROUGH_AUDIT.md vendored to anchor reviewer auditing of the
read-side handlers. Captures the field-name divergence (gas/gasLimit,
input/data, transactionIndex/index, flat-vs-nested signature) between
ethers v6 wrappers and the JSON-RPC spec, the chrome.runtime
structured-clone consequences, the three handlers fixed in this PR, and
the audit candidates left for follow-up review.

RETRO_uniswap_swap_dropped_tx.md captures the failure mode that drove
this work: hash returned to dApp → tx briefly visible on etherscan →
evicted → eth_getTransactionByHash returns null → Uniswap UI hangs.
Includes the audit findings on hardcoded EVM RPC bootstrap (separate
Pioneer-migration workstream) and the fix taxonomy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* diag(eth): decode + recover signer in broadcastTransaction

After the [HANDOFF] BEX → RPC broadcast log, parse the signed bytes via
ethers.Transaction.from(signedTx) and log every parsed field plus the
ECDSA-recovered signer. Compare to the dApp-supplied `from`. If they
mismatch, log [DECODE] ❌ MALFORMED-HEX at error level.

Threaded an `expectedFrom` argument through both broadcastTransaction
call sites (sendTransaction at line 1318 of approved-event flow, and
the alt path at line 825) so the comparison has the canonical
expected signer.

This instrumentation surfaced a release-blocker bug in EIP-1559 signing
upstream of the BEX — see RETRO_evm_tx_1559_signing_chain.md and
HANDOFF_evm_tx_1559_signing_chain.md. Imports `Transaction` from ethers
which is already a transitive dep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: EIP-1559 signing-chain retro + handoff (release blocker)

- RETRO_evm_tx_1559_signing_chain.md: the [DECODE] log added in the
  previous commit caught a release-blocker bug. sdk.eth.ethSignTransaction
  is returning serialized type-2 envelopes whose ECDSA signature does not
  recover to the device's address — recovers to a wrong-but-deterministic
  one (e.g. 0xEB152892… for the captured tx). 30+ candidate pre-image
  hashes ruled out (chainId variants, legacy fallback, missing 0x02
  prefix, field swap, data truncation/extension). Bug is in a non-canonical
  field encoding inside keepkey-vault-sdk and/or vault firmware. EIP-712
  signing through the same SDK works correctly, so the corruption is
  EIP-1559-specific.

- HANDOFF_evm_tx_1559_signing_chain.md: self-contained next-session
  handoff with reproducer commands, where to look in the SDK/firmware,
  what to ignore (the routing/Blink theories I hallucinated earlier),
  and a verification checklist for the eventual fix.

- RETRO_uniswap_swap_dropped_tx.md: marked SUPERSEDED with a banner
  pointing at the new retro. The fee-warning/drop-check/passthrough
  cleanups captured there are still real fixes for real other smells,
  but the framing of mempool eviction as the root cause was wrong —
  the real cause is the malformed-hex signature.

Companion test scaffolding lives in keepkey-vault-sdk (separate repo),
not committed here:
  tests/evm-tx-1559/recover-fixture.js
  tests/evm-tx-1559/sign-and-recover.js
  tests/fixtures/evm-tx-1559-regression.json

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(eth): address PR #55 review — lockfile, fail-closed signer, sign-tx fees, alarm-based drop check

- pnpm-lock.yaml: realign @types/chrome to ^0.0.280 so CI install passes
- broadcastTransaction: throw before broadcast when recovered signer ≠ expectedFrom
  (was logging only); pass-through ProviderRpcError so dApp sees the precise reason
- eth_signTransaction: pin chainId/from from active provider and apply feeChoice from
  the side-panel banner; previously 'Use suggested' / 'Custom' were no-ops on this path
- feeFloors.buildFeeWarning: enforce suggestedMax ≥ baseFee + suggestedPriority so
  'Use suggested' can never produce an effective tip below priorityFloor when the
  oracle's maxFee wins the choice
- drop-check: 45s probe migrated to chrome.alarms (survives MV3 SW suspension);
  manifest gains 'alarms' permission. 8s probe stays on setTimeout (best-effort,
  alarms minimum is 30s in production)
- FeeWarningBanner: drop unused @ts-expect-error flagged by reviewer

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: EIP-1559 signing chain — root cause is firmware (supersedes retro)

RETRO documented diagnostic dead-ends. The actual bug lives in
keepkey-firmware/lib/firmware/ethereum.c:891: when EIP-1559 tx-data exceeds
the 1024-byte single-USB-chunk threshold, the access-list 0xC0 byte is
hashed between the first chunk and the remainder, producing a non-canonical
pre-image. Single-chunk txs escape because the misplaced byte happens to
land at the end anyway. Universal Router swaps / Permit2 batches /
multicalls all hit it.

RESOLUTION_evm_tx_1559_signing_chain.md captures the unlock move
(EthereumTxRequest.hash field), the fix sketch, and the verification path.
Retro is kept with a banner pointing forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…re 7.14.1+) (#56)

* feat(signing): Tron/TON/Solana message signing via vault REST (firmware 7.14.1+)

Wire the 5 message-signing endpoints landed in vault commit 7e6dfc9:

  POST /tron/sign-message       (TIP-191 personal_sign)
  POST /tron/verify-message     (TIP-191 verify)
  POST /tron/sign-typed-hash    (TIP-712 hash mode)
  POST /ton/sign-message        (bare Ed25519, AdvancedMode-gated)
  POST /solana/sign-offchain-message (domain-separated envelope)

Replaces the "not yet supported" throw at tronHandler.ts:702 and adds
parallel surfaces on TON and Solana.

New: chrome-extension/src/background/firmware.ts — shared 7.14.1+ gate
called by every new handler before going to the device. 7.14.0 was an
internal stop-gap that never shipped publicly; treat the minimum as
7.14.1 so users on 7.14.0 see a clean upgrade prompt instead of a
Failure_UnknownMessage round-trip. Earlier firmware (7.13.x and below)
gets the same upgrade prompt.

Tron handler:
  - tron_signMessage / signMessage (V1, hex) and signMessageV2 (V2,
    UTF-8): both go through /tron/sign-message; V1 vs V2 only differ in
    wire encoding of the message bytes. Returns 0x-prefixed 65-byte
    recoverable signature to match TronWeb's expected return shape.
  - tron_verifyMessage / verifyMessage / verifyMessageV2: pure utility,
    doesn't touch the device — vault recovers the signer off-device.
  - tron_signTypedHash / _signTypedData: TIP-712 hash mode only. Caller
    pre-computes the 32-byte domainSeparator + message hashes; we don't
    ship a struct → hash implementation. dApps using TronWeb's
    _signTypedData should hash client-side then call this method.

Injected tron-provider.ts: signMessage / signMessageV2 /
verifyMessage / verifyMessageV2 now route to the backend instead of
throwing.

TON handler: ton_signMessage / signMessage. Firmware fences this behind
the AdvancedMode policy — when disabled, the vault returns a 4xx with
"AdvancedMode" in the body. Detect that and rewrite the error into an
actionable prompt ("Open Vault → Settings → Security and enable
Advanced Mode") instead of surfacing the raw HTTP failure.

Solana handler: solana_signOffchainMessage as a NEW method (does not
replace solana_signMessage — Wallet Standard expects bare-message
verification). The signature returned here is over the off-chain
envelope:

  "\xff" || "solana offchain" || version (1B) || format (1B)
                              || length (2B LE) || message

Verifiers MUST reconstruct the envelope and Ed25519-verify against
THAT, not the bare message bytes. The handler attaches a
`verifyAgainst: 'envelope'` hint to the approval event so any future
approval-card UI can warn the user about the verification model.
Defaults: version 0, messageFormat 1 (UTF-8). Caps at the firmware's
1212-byte limit.

Type-check: zero new errors introduced (verified vs. baseline on
develop).

* fix(signing): address PR review on TronWeb compat + Solana discoverability

1. Tron verifyMessageV2 was claiming TronWeb V2 compatibility but the
   shapes don't match: TronWeb V2 is verifyMessageV2(message, sig)
   returning the recovered base58 address; our endpoint takes an
   `address` arg and returns boolean. Standard dApps would fail on the
   2-arg call. Fix: drop verifyMessage / verifyMessageV2 from the
   tronWeb.trx shim entirely (verification is client-side anyway —
   TronWeb's static utilities cover it). Drop the verifyMessageV2 case
   alias from the background handler. Keep tron_verifyMessage as a
   non-standard utility reachable only via tronLink.request, with the
   shape changed to a single-object param so the contract is
   self-documenting.

2. The handler aliased _signTypedData to the hash-only API, but
   tronWeb.trx has no _signTypedData method exposed and TronWeb's real
   _signTypedData(domain, types, value) takes the full struct, not
   pre-computed hashes. Fix: drop the _signTypedData alias from the
   handler and from the docs — keep only tron_signTypedHash. Doc
   comment now explicitly notes we don't ship a struct → hash impl.

3. solana_signOffchainMessage was unreachable from page context — the
   Wallet Standard registration only advertised the three solana:*
   features and there's no window.keepkey.solana provider. Fix: add a
   vendor-namespaced 'keepkey:signOffchainMessage' feature to the
   wallet-standard registration. dApps can feature-detect via
   wallet.features['keepkey:signOffchainMessage'] and call
   .signOffchainMessage({ message, version?, messageFormat? }). Solana
   Wallet Standard explicitly reserves non-`solana:` namespaces for
   vendor extensions, so this is the canonical way to expose
   non-standard primitives.
* feat: Pioneer-sourced EVM chain registry + Solana sign-message UX

Replace static chains.ts with a Pioneer-backed registry. wallet_switchEthereumChain now does a one-step add+switch when Pioneer recognizes the chain (covers ~196 EVMs), falling back to a friendly chain-not-enabled card with a Chainlist link only when Pioneer also doesn't know it. Also fixes the Solana sign-message popup's "Unknown Method" / N-A details.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): chainId hex parsing, RPC override priority, off-chain message, badge cleanup

- P1: handleEthChainId/handleNetVersion accept either hex or decimal stored chainId. Pioneer-discovered chains store hex ('0x38') and parseInt('0x38', 10) was returning 0, so dApps were getting eth_chainId=0x0 after a Pioneer-driven switch.
- P2a: GET_ASSET_BALANCE and VALIDATE_ERC20_TOKEN now check blockchainDataStorage before falling to Pioneer, so user-customized RPCs in the Add Network UI take precedence on Pioneer-known chains too.
- P2b: RequestDetailsCard reads unsignedTx.messageUtf8/message for solana_signOffchainMessage (params[0] is an object there, not raw bytes — decodeMessage was producing an empty render).
- P3: ChainNotEnabledCard sends CLEAR_APPROVAL_BADGE on close; new background handler in index.ts clears the badge. Without this, dismissing the info card left the extension badge stuck on '!'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…inlist, typed timeouts (#58)

* feat(ux): risk-tiered approval colors, hex dump + copy, deep-link Chainlist, typed timeouts

- RequestMethodCard: color tiers encode risk — blue for read-only signMessage/signOffchainMessage, orange for signTransaction (signs, dApp broadcasts), red+warning icon for signAndSendTransaction (signs and broadcasts; irreversible).
- Off-chain message copy rewritten from dev-speak ('signature is over a Solana-defined wrapper') to user-facing guidance.
- ChainNotEnabledCard now shows known chain names (BNB Smart Chain, Polygon, etc.) above the hex/decimal pair, sourced from KNOWN_EVM_CHAINS. Chainlist link deep-links to /?search=<chainId> so the user lands on the right row.
- RequestDetailsCard: hex fallback formatted as a real hex dump (8 bytes per chunk, newline every 32) instead of one unbroken string. Copy-to-clipboard icon next to the message (CopyIcon → CheckIcon for 1.5s on success). Max-height bumped 240→360px for typical SIWS messages.
- New createTimeoutError helper in utils.ts; Solana/TON/Tron timeout sites use it. methods.ts forwards `kind` on the transaction_error message. Transaction.tsx prefers `errorKind === 'timeout'` over the legacy regex (regex retained as fallback for older error sources). Timeout copy clarifies the next step ('Reject the request in the dApp, then try again.').

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): add missing createTimeoutError import in solanaHandler

The first PR pass added createTimeoutError calls in four solanaHandler timeout paths but never updated the import line — the linter retouched the file before that edit landed and it got silently dropped. ReferenceError on every Solana timeout, type-check failures at lines 354/408/469/511.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant